Maurice Wu
Published on

非必要不使用 React.useEffect

React.useEffect 可以说是所有 hooks 中最难使用,最难用好的一个 hook 了。

它的第二个参数 dependencies ,当不穿,传空,传多个的时候意义完全不一样。

当 useEffect 的 dependencies 参数不传的时候,执行的时机相当于 class 组件的 componentDidUpdate 生命周期。

当 useEffect 的 dependencies 参数传空数组的时候,相当于 class 组件的 componentDidMountcomponentWillUnmount

当 useEffect 的 dependencies 参数需要传具体的依赖的时候,每当依赖项数组变化的时候,都会执行。

说它难使用主要是 dependencies 参数无法自动化,依赖于人去识别和判断, 增加复杂度。说它难用好是因为如果开发者很容易过度地使用 useEffect, 造成代码逻辑的混乱,形成屎山。

大量意义不明的空数组

当我们需要模拟 componentDidMount 的时候, 通常会传入一个空数组,表示这个 effect 只执行一次。

useEffect(() => {
  init()
}, [])

在代码中,充斥着大量这样的空数组,看上去很多余,而且不能直观地反映出这段代码的意图。我们可以通过自定义 hook 来解决这个问题。

const useInit = (init, dispose) => {
  React.useEffect(() => {
    if (isFunction(init)) {
      init()
    }
    return isFunction(dispose) ? dispose : undefined
  }, [])
}

闭包问题

在 useEffect 函数中,我们能访问到的组件 state 变量取决于该 effect 函数被定义时所在的作用域。也就是说,如果没有将所有访问到的 react 变量都加入依赖数组中,那么访问到的数据就有可能是过时的,不正确的。

但是我们一旦因为要解决闭包问题而降所有依赖项都加入,那么会遇到其他更麻烦的问题。

function ChatRoom = ({ roomId, theme }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId)
		connection.on('connected', () => {
			showNotification('Connected!', theme)
		})

		connection.connect();
		return () => connection.disconnect()
	}, [roomId, theme])
}

在上面的例子中,如果我们不将 theme 加入依赖数组中,在 connected 事件中访问到的 theme 变量可能是旧的值。当我们将其加入依赖数组之后,虽然解决了访问旧值的问题,但是每次修改 theme 的时候,都会断开和重连,这明显不是我们想要的。

我们可以将 theme 转换为 ref (例如 ahooks 中的 useLatest), 这样在 effect 函数中访问到的值永远都是最新的了。

过度的响应式编程

useEffect 最大的一个问题是容易让人以"监听"的方式去写代码。

// 渲染过程也依赖 pageNo 和 projectList 这两个状态
const [pageNo, setPageNo] = useState(1)
const [projectList, setProjectList] = useState([])
const handlePageNoBtnClick = (targetPageNo) => {
  setPageNo(targetPageNo)
}

const handleNextPageBtnClick = () => {
  const targetPageNo = pageNo + 1
  setPageNo(targetPageNo)
}

const handlePrevPageBtnClick = () => {
  const targetPageNo = pageNo - 1
  setPageNo(targetPageNo)
}

// 🟡 当 fetchProjectList 内的逻辑被修改后,难以评估这个改动的影响面
const fetchProjectList = useCallback(async () => {
  const query = qs.stringify({
    projectType: props.projectType, // projectType 是通过 props 获得的
    pageSize: 20,
    pageNo,
  })
  const response = await fetch(`/project/list?${query}`)
  const json = await response.json()
  setProjectList(json)
}, [pageNo, props.projectType])

useEffect(() => {
  fetchProjectList()
}, [fetchProjectList])

如上,分页的功能被切割成多个小的逻辑片段,依靠 useEffect 中的依赖项来连接。 例如: '点击下一页'的流程是 修改PageNo --> 触发 fetchProjectList chanaged --> 触发 useEffect,重新请求数据

虽然每个片段内的逻辑是完整的,但是片段与片段之间的联系并不直观和清晰,这给后续的维护带来很大的难度。

回到标题部分,为什么说非必要不使用 useEffect, 原因就在于这里。当我们的代码中充斥着这样的状态监听组成的逻辑,也就意味着这个项目已经变成屎山了。试想一下,这时候的 useEffect 不就是和 Vue 的 watch watchEffect 一样了吗,而 Vue 编程的一个准则就是在使用 watch 的时候,请思考3遍,是否真的需要 watch

useEffect 更偏向的应该是 effect 部分,即由状态变化触发的逻辑。 而像上面的例子中的上一页下一页 的功能都应该属于 Event 事件,不应该放在 Effect 中处理。

import { useCallback, useEffect, useState } from "react";

type PaginationProps = {
  autoLoad?: boolean;
  defaultPageNo?: number;
  children: (list: Record<string, unknown>[]) => React.ReactNode;
  fetch: (ctx: { pageNo: number, pageSize: number }) => Promise<{ total: number, data: Record<string, unknown>[] }>;
};

export function Pagination(props: PaginationProps) {
  const [pageNo, setPageNo] = useState(props.defaultPageNo || 0)
  const [total, setTotal] = useState(0)
  const [list, setList] = useState<Record<string, unknown>[]>([])
  const pageSize = 10


  const fetchData = useCallback(async (pageNo: number, pageSize: number) => {
    const { total, data } = await props.fetch({ pageNo, pageSize });
    setTotal(total);
    setList(data)
  }, [])

  useInit(() => {
    if (typeof props.autoLoad === 'undefined' ? true : props.autoLoad) {
      fetchData(pageNo, pageSize);
    }
  })

  const handlePrev = () => {
    const newPageNo = Math.max(0, pageNo - 1)
    setPageNo(newPageNo);
    fetchData(newPageNo, pageSize);
  }

  const handleNext = () => {
    const newPageNo = pageNo + 1;
    setPageNo(newPageNo);
    fetchData(newPageNo, pageSize);
  }

  return (
    <div>
      {props.children(list)}
      {/* Pagination controls will go here */}
      <button onClick={() => handlePrev()} disabled={pageNo <= 0}>
        Previous
      </button>
      <span>Page {pageNo}</span>
      <span>Total: {total}</span>
      <button onClick={() => handleNext()}>
        Next
      </button>
    </div>
  );
}